Appliance Completion Notification with Node Red
Changes
2023-08-08 V1.5 Changes to code uploaded. Added a measure of ‘peak vs off-peak savings, and split notifications into a sub flow in node red)
2022-07-01 V1.4 Added some notes on what else I have done since (at the end)
2021-04-12 V1.3 Fixed loop not detecting Finished-> Standby, – Moved some flow variables to context, – Changed csv filename to reflect appliance
Summary
A Node red flow (and Javascript) to
- monitor instantaneous power from an appliance
- check when it is operating, and use continual averaging to determine when complete.
- calculate cost of use (based on peak/offpeak power rates)
- push notify when complete (via various methods, and include cost/power data summary)
- voice notify when finished (selecting announcements randomly from a list)
- format notifications nicely, eg $, c, wH, kWH etc
- store power/cost/runtime in files (csv)
Getting instantaneous power use from an appliance
There are a number of ways of measuring power usage from an appliance/device that can be fed into this flow.
- Sonoff Pow modules with Tasmota (they report to MQTT) (Used for my washer & dryer)
- Sonoff Basic, with PEM-004T, using ESP Home and Home Assistant (used for my Oven)
- Pulse counting a check meter with a Wemos D1 Mini (and Tasmota again) (Used for my EV charging)
- A plug in Tuya style power monitoring mains switch, converted (with software if possible, or else hardware) (Used for my Dishwashers)
Goals
This was mostly a lesson in Javascript and Node Red. I previously used some simple rules in a sonoff POW running tasmota to monitor when my washing machine and dryer completed. I wanted also to expand this to items like my EV wallcharger, dishwasher etc.
The Tasmota rule method wasn’t particularly reliable (occasionally falsely told me the laundry had finished in the middle of the night), so I looked at doing the same thing in Node Red to make it reliable and adding features such as calculating the cost of the power used.
I found this project from notenoughtech, but it really didn’t do what I wanted. It was a great start however and gave me the opportunity to learn more Javascript and how Node Red function nodes can be built. I found what he had done a little confusing, not greatly documented (for my pedantic mind anyway) and parts of the code seemed to be there from previous iterations (and did nothing). Finally, I learned about context and flow variables and simplified as much as I could, and only using flow variables where needed.
What does it do
The main crux of the code monitors the power from MQTT (from something like a Sonoff Pow V2, or in my case also a pulse based check meter for my EV charging) and takes an average over a few readings. If it looks like the appliance has started up, it will start calculating the power usage and cost of power (based on $/kWh, and can use peak/offpeak pricing). If the power usage average looks like the appliance is finished, it notifies (via various methods such as a google home random voice message, pushover phone push message or other methods you can easily add) of the completion, and the cost of power used.
A later addition was storing data in a CSV file. Useful as a backup or if you need a summary to use in calculations later.
The code is (now) well documented and there is a settings node that allows you to tweak pretty much all settings in one place, including such things as the type of appliance (washer, dryer etc… so the notifications are correct), the words to use to notify, the approx standby power of the device, how many readings to use for an average, and the electricity day/night tariff.
There are also a bunch of debug nodes and inject nodes for testing (mainly the JSON and notifications etc) as I wouldn’t have been able to figure it all out without sensible debugging.
There is certainly more efficiencies to be had and improvements to the code, (I have tidier code in my Oven Monitoring project, which is similar but more mature now) but I’m quite happy with it and it has taught me a lot. There is still small amounts of redundant code, and I have ideas to extend it. Node Red is great, but with function nodes and Javascript you are pretty much limitless in what you can achieve.
Push Notifications
An example notification (using Pushover) on Android and iphone. It could equally be used over email, twitter, text messaging or another push notification system
Pushover: https://pushover.net/ (non free, but only a one off cost for each device)
Dryer Notification Your drying is complete. It started at 21:04, finished at 22:40, and used 2.8kWh, taking 01h 36min at a cost of <1c (Offpeak savings of 57c)
Data to CSV File
An example of how it pushes to a CSV file (which is added to each time)
{"date/time":"2021-04-08T03:01:54.027Z","Appliance":"EV Charger","Start Time":"15:01","Finish Time":"15:01","Cycle Time Formatted":"00min","Cycle Time (s)":13.295000076293945,"Cycle Power Use Formatted":"179.3Wh","Cycle Power Use (Wh)":179.33333333333331,"Cycle Cost Formatted":"3c","Cycle Cost ($)":0.033282}
Node Red flows to download
What’s Next
Some additions to this I am planning (or have already implemented since this article)
- I have included a ‘Quiet Time’ process in my home assistant setup (and a subsequent subflow in Node Red). It turns on once we tell it we have gone to bed. This prevents announcements at night etc.
- I have gone away from google home announcements (using the cloud) and have my touchscreen lounge PC set up with MPD, and it announces things there instead.
- I am pushing completion notices to an MQTT feed, and using the Lovalace: Home Card Feed in Home Assistant to make a nice list of when things have recently completed
- Some of the code is repeated as I have different nodes (eg the CSV file code uses calcs that are also in the main loop) so I plan to make this more efficient.
- Other notifications planned (eg coloured LED flashes for various item completions)
Configuration Example
This is the javascript for the ‘On Start’ tab in the ‘Settings’ Function node.
This is changed depending on the appliance. Currently I’m monitoring & reporting on my EV charger (with a pulse check meter), my washing machine and dryer (with Sonoff POW modules) and a dishwasher with a cheap plugin Kogan power switch/meter (that I had to hardware modify)
// -----------------------------------------
// Notify and store power use and cost for an appliance
// FLOW VARIABLES
// Full loop to calculate power average of appliance and
// work out when it is operating. When operational
// fill arrays with power used, and cost of power calculated.
// 2023-08-08 V1.4 - zorruno
// - Minor updates to flow variables
// - add a variable to calculate '$ savings from peak power'
// -----------------------------------------
// 2021-04-12 V1.3 - zorruno
// - Fixed loop not detecting Finished-> Standby
// - Moved some flow variables to context
// - Changed csv filename to reflect appliance
// -----------------------------------------
// Debugging nodes
flow.set("debugFlow", false);
// Announcement Topic
flow.set("announcementTopic", "My Home");
// MQTT Feed Activity Topic
flow.set("mqttActivityTopic", "myhome-status/activityfeed/");
// Announcement Channels
flow.set("announcementChannels",
"lounge_touchscreen_announce," +
"pushover_myandroid," +
"pushover_wife_iphone," +
"sony_tv," +
"mqtt_feed," +
"csv_summary"
);
// Appliance Names (for notifications)
flow.set("applianceName", "Washer");
flow.set("applianceAction", "washing");
//flow.set("JSONPowerTopic", msg.payload.ENERGY.Power);
// Announcement text (for voice notifications)
flow.set("voiceAnnouncements", [
"Hey, the washing machine is now complete",
"Hey, your washing is complete",
"The washing is done",
"The washing machine has finished",
"Washing has now finished",
]);
// Electrical tariff info
// Updated with Auckland Contact Energy Costs, Feb 2021
// cost is in cents per kWh and start and end are the times
// to change tariffs.
// Cost is in $ per kWh
var tariff = {
"costDay": 0.2023,
"costNight": 0.0,
"start": 0,
"end": 21
};
//---------------------------------------
// Appliance power settings
//---------------------------------------
// Average power in standby mode (it will show off if average
// power is above this and below the operatingPowerMinimum)
// Currently this just affects showing 'standby' vs 'off'
// so actual value is not critical.
flow.set("standbyPower", 2);
// Minimum power when actually operating.
// This is averaged over 'resolution' values, so no problem
// if it drops below this value at times during operation.
flow.set("operatingPowerMinium", 11);
// How many times to do a power reading for rolling average.
flow.set("resolution", 7);
// How often does the appliance report back (seconds)
flow.set("metricFrequency", 60);
//---------------------------------------
// No need to change these
//---------------------------------------
flow.set("tariff", tariff);
flow.set("recentPowerArray", [0]);
flow.set("cycleCostArray", [0]);
flow.set("cyclePowerArray", [0]);
var cycle = {
"cycleTimeStart": null,
"cycleTimeStop": null,
"totalCycleCostFormatted": 0,
"totalCycleCostDollars": 0,
"totalCyclePowerFormatted": 0,
"totalCyclePowerWattHours": 0,
"totalCyclePeakSavings": 0,
}
flow.set("currentApplianceCycle",cycle);
Main Loop (for EV Charger Monitoring)
// -----------------------------------------
// Notify and store power use and cost for an appliance
// MAIN LOOP
// Full loop to calculate power average of appliance and
// work out when it is operating. When operational
// fill arrays with power used, and cost of power calculated.
// -----------------------------------------
// 2023-08-03 V1.4 - zorruno
// - tidyup for sending better notifications
// - calculate peakSavings, the $ savings based on running offpeak load
// -----------------------------------------
// 2021-04-12 V1.3 - zorruno
// - Fixed loop not detecting Finished-> Standby
// - Moved some flow variables to context
// - Changed csv filename to reflect appliance
// -----------------------------------------
// The input message must be the current power in Watts
var power = msg.payload;
// Get flow user settings variables into local variables
var res = flow.get("resolution");
var tariff = flow.get("tariff");
var metricsf = flow.get("metricFrequency");
var standby = flow.get("standbyPower");
var opPowerMin = flow.get("operatingPowerMinium");
var currentCycle = flow.get("currentApplianceCycle");
// Get flow variables into local variables
var operation = context.get("operation") || "Off";
var recentPowerArray = flow.get("recentPowerArray") || [0];
var cycleCostArray = flow.get("cycleCostArray") || [0];
var cyclePowerArray = flow.get("cyclePowerArray") || [0];
// Get date, seconds and hours
var date = new Date();
var dateS = date.getTime() / 1000;
var hour = date.getHours();
// -----------------------------------------
// Fill power array, and calculate average
// power. Do this on every loop.
// -----------------------------------------
// Function add for reduce array
function add(accumulator, a) {
return accumulator + a;
}
// Push power into TotalPower array for average
recentPowerArray.unshift(power);
// Remove X element to get total resolution for average calc
if (recentPowerArray[res] === undefined) {
flow.set("recentPowerArray", recentPowerArray);
}
else {
recentPowerArray.splice(res, 1);
flow.set("recentPowerArray", recentPowerArray);
}
// Calculate average power from array
var sum = recentPowerArray;
var average = (sum.reduce(add) / recentPowerArray.length); // Average the array
// -----------------------------------------
// Fill cycle power array, and calculate average power.
// Only do this when cycle is occurring.
// Note this method doesn't capture EVERY data point -
// we'll miss the first few as we are calculating an
// average. We could capture more, but that is less efficient.
// -----------------------------------------
if (operation === "Operating") {
// Push watthours into cyclePowerArray for cycle
var wattHoursNow = power / (60 * (60 / metricsf));
cyclePowerArray.push(wattHoursNow);
flow.set("cyclePowerArray", cyclePowerArray);
// Calculate the cost of power
var price;
if (hour >= tariff.start && hour < tariff.end) {
price = tariff.costDay; // Apply day tariff
}
if (hour < tariff.start || hour >= tariff.end) {
price = tariff.costNight; // Apply night tariff
}
// Fill cycleCostArray
var costPerMinute = power / 1000 * price / (60 * (60 / metricsf));
cycleCostArray.push(costPerMinute);
flow.set("cycleCostArray", cycleCostArray); // Add to cost array
}
// -----------------------------------------
// Appliance is off
// -----------------------------------------
//if(average === 0){
if (average < standby) {
context.set("operation", "Off");
}
// -----------------------------------------
// Appliance has gone into Standby from Off
// -----------------------------------------
if (average >= standby && operation === "Off") {
context.set("operation", "Standby");
}
// -----------------------------------------
// Appliance has gone into Standby from Finished
// -----------------------------------------
if (average >= standby && operation === "Finished") {
context.set("operation", "Standby");
}
// -----------------------------------------
// Appliance has started its Operating cycle
// from Standby or Off
// -----------------------------------------
if ((average > opPowerMin && operation === "Standby") || (average > opPowerMin && operation === "Off")) {
context.set("operation", "Operating");
currentCycle.cycleTimeStart = dateS;
cycleCostArray = [0]; // Clear array to start cycle
flow.set("cycleCostArray", cycleCostArray);
cyclePowerArray = [0]; // Clear array to start cycle
flow.set("cyclePowerArray", cyclePowerArray);
}
// -----------------------------------------
// Appliance was in Operating cycle,
// but now has Finished
// -----------------------------------------
if (average < opPowerMin && operation === "Operating") {
context.set("operation", "Finished");
currentCycle.cycleTimeStop = dateS;
// Calculate & format total cost of the entire cycle
// in $
var sumCost = flow.get("cycleCostArray");
var costOfPower = sumCost.reduce(add);
currentCycle.totalCycleCostDollars = costOfPower;
// Calculate & format total power use of the entire cycle
// in kWh
var sumPower = flow.get("cyclePowerArray");
var sumOfPower = sumPower.reduce(add);
currentCycle.totalCyclePowerWattHours = sumOfPower;
// Calculate savings based on if we ran it as a peak load
// peak tarrif in $ per kWh, multiplied by power used in kWh (sumOfPower/1000)
// then subtract the actual power cost in $
var offpeakSavings = (tariff.costDay * sumOfPower / 1000);
offpeakSavings = offpeakSavings - costOfPower;
// Format costOfPower as $ or cents
if (costOfPower < 0.01) {
costOfPower = '<1c'; 3
} else if (costOfPower < 1) {
costOfPower = costOfPower * 100;
costOfPower = Math.round(costOfPower);
costOfPower = costOfPower.toString() + 'c';
} else {
costOfPower = costOfPower.toFixed(2);
costOfPower = '$' + costOfPower.toString();
}
currentCycle.totalCycleCostFormatted = costOfPower;
// Format peakSavings as $ or cents
if (offpeakSavings < 0.01) {
offpeakSavings = '<1c';
} else if (offpeakSavings < 1) {
offpeakSavings = offpeakSavings * 100;
offpeakSavings = Math.round(offpeakSavings);
offpeakSavings = offpeakSavings.toString() + 'c';
} else {
offpeakSavings = offpeakSavings.toFixed(2);
offpeakSavings = '$' + offpeakSavings.toString();
}
currentCycle.totalCycleOffpeakSavings = offpeakSavings;
// Format power as wH or kWh
if (sumOfPower >= 1000) {
sumOfPower = sumOfPower / 1000;
sumOfPower = sumOfPower.toFixed(1); // 1 decimal place
sumOfPower = sumOfPower.toString() + 'kWh';
} else if (costOfPower < 1) {
costOfPower = '<1Wh';
} else {
sumOfPower = sumOfPower.toFixed(1); // 1 decimal place
sumOfPower = sumOfPower.toString() + 'Wh';
}
currentCycle.totalCyclePowerFormatted = sumOfPower;
msg.payload = context.get("operation");
return [msg, msg]
}
// -----------------------------------------
// Output debug stuff on each loop
// -----------------------------------------
if (flow.get("debugFlow") === true) {
flow.set("debugAverage", average);
// Calculate total cost of the entire cycle so far
var costSoFar = flow.get("cycleCostArray").reduce(add);
flow.set("debugCostSoFar", costSoFar);
// Calculate total power use of the entire cycle so far
var powerSoFar = flow.get("cyclePowerArray").reduce(add);
flow.set("debugPowerSoFar", powerSoFar);
flow.set("debugCyclePowerArray", cyclePowerArray);
flow.set("debugCycleCostArray", cycleCostArray);
flow.set("debugRecentPowerArray", recentPowerArray);
msg.payload = context.get("operation");
return [null, msg]
}// -----------------------------------------
// Notify and store power use and cost for an appliance
// MAIN LOOP
// Full loop to calculate power average of appliance and
// work out when it is operating. When operational
// fill arrays with power used, and cost of power calculated.
// -----------------------------------------
// 2023-08-03 V1.4 - zorruno
// - tidyup for sending better notifications
// - calculate peakSavings, the $ savings based on running offpeak load
// -----------------------------------------
// 2021-04-12 V1.3 - zorruno
// - Fixed loop not detecting Finished-> Standby
// - Moved some flow variables to context
// - Changed csv filename to reflect appliance
// -----------------------------------------
// The input message must be the current power in Watts
var power = msg.payload;
// Get flow user settings variables into local variables
var res = flow.get("resolution");
var tariff = flow.get("tariff");
var metricsf = flow.get("metricFrequency");
var standby = flow.get("standbyPower");
var opPowerMin = flow.get("operatingPowerMinium");
var currentCycle = flow.get("currentApplianceCycle");
// Get flow variables into local variables
var operation = context.get("operation") || "Off";
var recentPowerArray = flow.get("recentPowerArray") || [0];
var cycleCostArray = flow.get("cycleCostArray") || [0];
var cyclePowerArray = flow.get("cyclePowerArray") || [0];
// Get date, seconds and hours
var date = new Date();
var dateS = date.getTime() / 1000;
var hour = date.getHours();
// -----------------------------------------
// Fill power array, and calculate average
// power. Do this on every loop.
// -----------------------------------------
// Function add for reduce array
function add(accumulator, a) {
return accumulator + a;
}
// Push power into TotalPower array for average
recentPowerArray.unshift(power);
// Remove X element to get total resolution for average calc
if (recentPowerArray[res] === undefined) {
flow.set("recentPowerArray", recentPowerArray);
}
else {
recentPowerArray.splice(res, 1);
flow.set("recentPowerArray", recentPowerArray);
}
// Calculate average power from array
var sum = recentPowerArray;
var average = (sum.reduce(add) / recentPowerArray.length); // Average the array
// -----------------------------------------
// Fill cycle power array, and calculate average power.
// Only do this when cycle is occurring.
// Note this method doesn't capture EVERY data point -
// we'll miss the first few as we are calculating an
// average. We could capture more, but that is less efficient.
// -----------------------------------------
if (operation === "Operating") {
// Push watthours into cyclePowerArray for cycle
var wattHoursNow = power / (60 * (60 / metricsf));
cyclePowerArray.push(wattHoursNow);
flow.set("cyclePowerArray", cyclePowerArray);
// Calculate the cost of power
var price;
if (hour >= tariff.start && hour < tariff.end) {
price = tariff.costDay; // Apply day tariff
}
if (hour < tariff.start || hour >= tariff.end) {
price = tariff.costNight; // Apply night tariff
}
// Fill cycleCostArray
var costPerMinute = power / 1000 * price / (60 * (60 / metricsf));
cycleCostArray.push(costPerMinute);
flow.set("cycleCostArray", cycleCostArray); // Add to cost array
}
// -----------------------------------------
// Appliance is off
// -----------------------------------------
//if(average === 0){
if (average < standby) {
context.set("operation", "Off");
}
// -----------------------------------------
// Appliance has gone into Standby from Off
// -----------------------------------------
if (average >= standby && operation === "Off") {
context.set("operation", "Standby");
}
// -----------------------------------------
// Appliance has gone into Standby from Finished
// -----------------------------------------
if (average >= standby && operation === "Finished") {
context.set("operation", "Standby");
}
// -----------------------------------------
// Appliance has started its Operating cycle
// from Standby or Off
// -----------------------------------------
if ((average > opPowerMin && operation === "Standby") || (average > opPowerMin && operation === "Off")) {
context.set("operation", "Operating");
currentCycle.cycleTimeStart = dateS;
cycleCostArray = [0]; // Clear array to start cycle
flow.set("cycleCostArray", cycleCostArray);
cyclePowerArray = [0]; // Clear array to start cycle
flow.set("cyclePowerArray", cyclePowerArray);
}
// -----------------------------------------
// Appliance was in Operating cycle,
// but now has Finished
// -----------------------------------------
if (average < opPowerMin && operation === "Operating") {
context.set("operation", "Finished");
currentCycle.cycleTimeStop = dateS;
// Calculate & format total cost of the entire cycle
// in $
var sumCost = flow.get("cycleCostArray");
var costOfPower = sumCost.reduce(add);
currentCycle.totalCycleCostDollars = costOfPower;
// Calculate & format total power use of the entire cycle
// in kWh
var sumPower = flow.get("cyclePowerArray");
var sumOfPower = sumPower.reduce(add);
currentCycle.totalCyclePowerWattHours = sumOfPower;
// Calculate savings based on if we ran it as a peak load
// peak tarrif in $ per kWh, multiplied by power used in kWh (sumOfPower/1000)
// then subtract the actual power cost in $
var offpeakSavings = (tariff.costDay * sumOfPower / 1000);
offpeakSavings = offpeakSavings - costOfPower;
// Format costOfPower as $ or cents
if (costOfPower < 0.01) {
costOfPower = '<1c'; 3
} else if (costOfPower < 1) {
costOfPower = costOfPower * 100;
costOfPower = Math.round(costOfPower);
costOfPower = costOfPower.toString() + 'c';
} else {
costOfPower = costOfPower.toFixed(2);
costOfPower = '$' + costOfPower.toString();
}
currentCycle.totalCycleCostFormatted = costOfPower;
// Format peakSavings as $ or cents
if (offpeakSavings < 0.01) {
offpeakSavings = '<1c';
} else if (offpeakSavings < 1) {
offpeakSavings = offpeakSavings * 100;
offpeakSavings = Math.round(offpeakSavings);
offpeakSavings = offpeakSavings.toString() + 'c';
} else {
offpeakSavings = offpeakSavings.toFixed(2);
offpeakSavings = '$' + offpeakSavings.toString();
}
currentCycle.totalCycleOffpeakSavings = offpeakSavings;
// Format power as wH or kWh
if (sumOfPower >= 1000) {
sumOfPower = sumOfPower / 1000;
sumOfPower = sumOfPower.toFixed(1); // 1 decimal place
sumOfPower = sumOfPower.toString() + 'kWh';
} else if (costOfPower < 1) {
costOfPower = '<1Wh';
} else {
sumOfPower = sumOfPower.toFixed(1); // 1 decimal place
sumOfPower = sumOfPower.toString() + 'Wh';
}
currentCycle.totalCyclePowerFormatted = sumOfPower;
msg.payload = context.get("operation");
return [msg, msg]
}
// -----------------------------------------
// Output debug stuff on each loop
// -----------------------------------------
if (flow.get("debugFlow") === true) {
flow.set("debugAverage", average);
// Calculate total cost of the entire cycle so far
var costSoFar = flow.get("cycleCostArray").reduce(add);
flow.set("debugCostSoFar", costSoFar);
// Calculate total power use of the entire cycle so far
var powerSoFar = flow.get("cyclePowerArray").reduce(add);
flow.set("debugPowerSoFar", powerSoFar);
flow.set("debugCyclePowerArray", cyclePowerArray);
flow.set("debugCycleCostArray", cycleCostArray);
flow.set("debugRecentPowerArray", recentPowerArray);
msg.payload = context.get("operation");
return [null, msg]
}